今天來講 useEventListener 的單元測試,大部分是針對傳入不同型別的參數做的測試。相關說明會放在程式碼註解中,如果有遇到需要描述更多的會再放到程式碼區塊外面~
在 DAY 8 的時候有介紹過參數以及實作參數處理的部分,那時候用比較簡單的方式呈現,現在因為 unit test 大部分都是針對傳入的參數做對應的測試,所以那時候簡易實作版本會造成有些測試失敗,在這邊先做一下調整,以下貼上修改後的 useEventLisnener,邏輯部分都沒變,也可以參考本文最後的 GitHub PR 看詳細的差別~
// src/compositions/useEventListener.js
export function useEventListener(...args) {
let target
let events
let listeners
let options
if (typeof args[0] === 'string' || Array.isArray(args[0])) {
[events, listeners, options] = args
target = defaultWindow
}
else {
[target, events, listeners, options] = args
}
if (!target)
return noop
if (!Array.isArray(events))
events = [events]
if (!Array.isArray(listeners))
listeners = [listeners]
// 用來收集 removeEventListener function
const cleanups = []
const cleanup = () => {
cleanups.forEach(cleanup => cleanup())
cleanups.length = 0
}
const register = (el, event, listener, options) => {
el.addEventListener(event, listener, options)
return () => el.removeEventListener(event, listener, options)
}
const stopWatch = watch(
() => [unrefElement(target), toValue(options)],
([el, options]) => {
cleanup()
if (!el)
return
const optionsClone = isObject(options) ? { ...options } : options
cleanups.push(...events.flatMap((event) => {
return listeners.map(listener => register(el, event, listener, optionsClone))
}))
},
{ immediate: true, flush: 'post' },
)
const stop = () => {
stopWatch()
cleanup()
}
tryOnScopeDispose(stop)
return stop
};
後續的案例都會放在 // 測試案例
這個註解的層級位置
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { isVue2, nextTick, ref } from 'vue'
import { useEventListener } from './useEventListener'
import { noop } from '@/helper'
describe('useEventListener', () => {
const options = { capture: true }
let stop
let target
let removeSpy
let addSpy
beforeEach(() => {
target = document.createElement('div')
removeSpy = vi.spyOn(target, 'removeEventListener')
addSpy = vi.spyOn(target, 'addEventListener')
})
// 測試案例
})
removeSpy
、addSpy
用 spyOn 的方式 mock target 物件的 removeEventListener
、addEventListener
method,後續如果要測試這兩個 method 是否有被觸發,可以用以下方式做測試:
expect(removeSpy).toBeCalledTimes(1)
expect(addSpy).toBeCalledTimes(1)
因為是放在 beforeEach
中,所以每次跑測試案例時,removeSpy
、addSpy
都會被重新建立,可以保持測試案例的一致性。
先從最單純的參數開始測試,event
跟 listener
都不是陣列(只有一個)
describe('give both none array', () => {
// mock listener function
const listener = vi.fn()
const event = 'click'
beforeEach(() => {
listener.mockReset()
stop = useEventListener(target, event, listener, options)
})
it('should add listener', () => {
// 因為 beforeEach 有執行一次 useEventListener,所以 target 的 addEventListener 被執行一次
expect(addSpy).toBeCalledTimes(1)
})
it('should trigger listener', () => {
expect(listener).not.toBeCalled()
target.dispatchEvent(new MouseEvent('click'))
// target 觸發 click event 後,listener 被執行一次
expect(listener).toBeCalledTimes(1)
})
it('should remove listener', () => {
expect(removeSpy).not.toBeCalled()
stop()
// 執行 useEventListener return 的 stop function,target 的 removeEventListener 被執行一次
expect(removeSpy).toBeCalledTimes(1)
// 檢查 target 的 removeEventListener 被執行的時候,參數是否跟之前 addEventListener 的參數一樣
expect(removeSpy).toBeCalledWith(event, listener, options)
})
})
should add listener, should trigger listener, should remove listener 這三個案例會在後續一直出現。測試的內容差不多,只是會因為參數不同,內部實作稍有不同,有些直觀的測試就純粹在這邊列出,應該也不太需要註解或說明~
describe('given array of events but single listener', () => {
const listener = vi.fn()
const events = ['click', 'scroll', 'blur', 'resize']
beforeEach(() => {
listener.mockReset()
stop = useEventListener(target, events, listener, options)
})
it('should add listener for all events', () => {
events.forEach(event => expect(addSpy).toBeCalledWith(event, listener, options))
})
it('should trigger listener with all events', () => {
expect(listener).not.toBeCalled()
events.forEach((event, index) => {
target.dispatchEvent(new Event(event))
expect(listener).toBeCalledTimes(index + 1)
})
})
it('should remove listener with all events', () => {
expect(removeSpy).not.toBeCalled()
stop()
expect(removeSpy).toBeCalledTimes(events.length)
events.forEach(event => expect(removeSpy).toBeCalledWith(event, listener, options))
})
})
describe('given single event but array of listeners', () => {
const listeners = [vi.fn(), vi.fn(), vi.fn()]
const event = 'click'
beforeEach(() => {
listeners.forEach(listener => listener.mockReset())
stop = useEventListener(target, event, listeners, options)
})
it('should add all listeners', () => {
listeners.forEach(listener => expect(addSpy).toBeCalledWith(event, listener, options))
})
it('should call all listeners with single click event', () => {
listeners.forEach(listener => expect(listener).not.toBeCalled())
target.dispatchEvent(new Event(event))
listeners.forEach(listener => expect(listener).toBeCalledTimes(1))
})
it('should remove listeners', () => {
expect(removeSpy).not.toBeCalled()
stop()
expect(removeSpy).toBeCalledTimes(listeners.length)
listeners.forEach(listener => expect(removeSpy).toBeCalledWith(event, listener, options))
})
})
describe('given both array of events and listeners', () => {
const listeners = [vi.fn(), vi.fn(), vi.fn()]
const events = ['click', 'scroll', 'blur', 'resize', 'custom-event']
beforeEach(() => {
listeners.forEach(listener => listener.mockReset())
stop = useEventListener(target, events, listeners, options)
})
it('should add all listeners for all events', () => {
listeners.forEach((listener) => {
events.forEach((event) => {
expect(addSpy).toBeCalledWith(event, listener, options)
})
})
})
it('should call all listeners with all events', () => {
events.forEach((event, index) => {
target.dispatchEvent(new Event(event))
listeners.forEach(listener => expect(listener).toBeCalledTimes(index + 1))
})
})
it('should remove all listeners with all events', () => {
expect(removeSpy).not.toBeCalled()
stop()
listeners.forEach((listener) => {
events.forEach((event) => {
expect(removeSpy).toBeCalledWith(event, listener, options)
})
})
})
})
後續的案例都會放在 // 例外情境測試案例
這個註解的層級位置
describe('multiple events', () => {
let target
let listener
beforeEach(() => {
target = ref(document.createElement('div'))
listener = vi.fn()
})
// 例外情境測試案例
})
it('should not listen when target is invalid', async () => {
useEventListener(target, 'click', listener)
const el = target.value
target.value = null
await nextTick()
el?.dispatchEvent(new MouseEvent('click'))
await nextTick()
expect(listener).toHaveBeenCalledTimes(0)
expect(useEventListener(null, 'click', listener)).toBe(noop)
})
這個單元測試案例滿好玩的,
昨天有提到 target 可以傳入 ref 物件,target 也正好是 watch 觀察的對象,所以當程式碼執行到 target.value = null
時,會執行 watch callback 裡面的邏輯:
// src/compositions/useEventListener.js
const stopWatch = watch(
() => [unrefElement(target), toValue(options)],
([el, options]) => {
cleanup()
if (!el)
return
就在這邊被 return 掉了,而第一次因為 { immediate: true }
註冊的那些監聽器,也在 return 之前被 cleanup()
清掉了,所以測試程式執行到 el?.dispatchEvent(new MouseEvent('click'))
的時候就不會再觸發 listener。
function getTargetName(useTarget) {
return useTarget ? 'element' : 'window'
}
function getArgs(useTarget) {
return (useTarget ? [target, 'click', listener] : ['click', listener])
}
function trigger(useTarget) {
(useTarget ? target.value : window).dispatchEvent(new MouseEvent('click'))
}
function testTarget(useTarget) {
it(`should ${getTargetName(useTarget)} listen event`, async () => {
useEventListener(...getArgs(useTarget))
trigger(useTarget)
await nextTick()
expect(listener).toHaveBeenCalledTimes(1)
})
it(`should ${getTargetName(useTarget)} manually stop listening event`, async () => {
const stop = useEventListener(...getArgs(useTarget))
stop()
trigger(useTarget)
await nextTick()
// 測試手動執行 stop() 後觸發的事件,listener 不能被執行
expect(listener).toHaveBeenCalledTimes(0)
})
it(`should ${getTargetName(useTarget)} auto stop listening event`, async () => {
const scope = effectScope()
await scope.run(async () => {
useEventListener(...getArgs(useTarget))
})
await scope.stop()
trigger(useTarget)
await nextTick()
expect(listener).toHaveBeenCalledTimes(isVue2 ? 1 : 0)
})
}
testTarget(false) // 不傳 target,target 預設必須是 window
testTarget(true) // target 是 div element
這段有點大段,測試起點在 testTarget(false)
、testTarget(true)
,可參考相關註解。
這段最有趣的就是 should element auto stop listening event
這個案例了(因為變數有點難讀,這邊先用 element 取代原本變數的部分),這個案例的測試程式碼我底下再寫一次:
it('should element auto stop listening event', async () => {
const scope = effectScope()
await scope.run(async () => {
useEventListener(...getArgs(useTarget))
})
await scope.stop()
trigger(useTarget)
await nextTick()
expect(listener).toHaveBeenCalledTimes(isVue2 ? 1 : 0)
})
前面自己手動建立一個 effect scope,然後透過 await scope.stop()
來觸發我們 DAY 8 看到的 tryOnScopeDispose
,
所以後面觸發事件,listener 都不會再被執行。然後看看這個 isVue2 ? 1 : 0
,顯然 Vue2 沒有 effectScope() 這種東西 XD
關於 effectScope()
,可以參考官方文件說明:https://cn.vuejs.org/api/reactivity-advanced#effectscope
it.skipIf(isVue2)('should auto re-register', async () => {
const target = ref()
const listener = vi.fn()
const options = ref(false)
useEventListener(target, 'click', listener, options)
const el = document.createElement('div')
const addSpy = vi.spyOn(el, 'addEventListener')
const removeSpy = vi.spyOn(el, 'removeEventListener')
target.value = el
await nextTick()
expect(addSpy).toHaveBeenCalledTimes(1)
expect(addSpy).toHaveBeenLastCalledWith('click', listener, false)
expect(removeSpy).toHaveBeenCalledTimes(0)
options.value = true
await nextTick()
expect(addSpy).toHaveBeenCalledTimes(2)
expect(addSpy).toHaveBeenLastCalledWith('click', listener, true)
expect(removeSpy).toHaveBeenCalledTimes(1)
})
接下來可以到 Day9 的 register & cleanup 區塊,對照著看比較有感覺。
target.value 一開始是 undefined,進到 watch callback 會被 return,所以不會註冊任何東西。
測試程式執行到 target.value = el
的時候觸發 watch callback,因為第一次執行沒註冊任何東西,所以 cleanups array 是空的,removeSpy 也就不會被執行到。
測試程式執行到 options.value = true
的時候觸發 watch callback,這次會把上次註冊的東西都清掉,所以 removeSpy 會被執行。
GitHub:https://github.com/RhinoLee/30days_vue/pull/9/files
因為是最近工作才開始寫 unit test,在看 unit test 的 source code 總是可以學到很多用法,如果後面沒有想看的 API,可以一直看 unit test 就好(誤)
useEventListener 在這邊也搞一段落了,明天會開始看 useMouse~